Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 64a39a554b5dd6a0c13dce1c4400981647334b0e


Parents : aadc058
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-26T08:45:49-05:00

feat: fix language switching functionality by implementing immediate locale application and improving i18n registration

Changes
Diff

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 7da223fc..5072622a 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index fce443a3..b771b0d6 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1993,13 +1993,14 @@ export default {
},
async onLanguageChange(langCode) {
const code = normalizeUiLocaleCode(langCode);
+ // Switch UI first so a slow or failed PATCH cannot leave the shell stuck on English.
+ await this.applyLocale(code);
await this.updateConfig(
{
language: code,
},
"language"
);
- await this.applyLocale(code);
},
async composeNewMessage() {
// go to messages route

diff --git a/meshchatx/src/frontend/components/LanguageSelector.vue b/meshchatx/src/frontend/components/LanguageSelector.vue
index 0d95b11d..6aeca171 100644
--- a/meshchatx/src/frontend/components/LanguageSelector.vue
+++ b/meshchatx/src/frontend/components/LanguageSelector.vue
@@ -30,7 +30,7 @@
currentLanguage === lang.code,
'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
}"
- @click="selectLanguage(lang.code)"
+ @click.stop="selectLanguage(lang.code)"
>
<span class="font-medium">{{ lang.name }}</span>
<MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
@@ -44,7 +44,7 @@
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";
-import { ensureLocaleMessages, listLocaleCodes } from "../js/localeLoader.js";
+import { ensureLocaleMessages, listLocaleCodes, setLocale } from "../js/localeLoader.js";
const LANGUAGE_NAMES = {
de: "Deutsch",
@@ -145,8 +145,13 @@ export default {
return;
}
+ // Apply immediately. Parent persists config. Options API this.$i18n is a
+ // locale-only proxy under legacy:false so setLocale uses registerUiI18n.
try {
- await ensureLocaleMessages(this.$i18n, langCode);
+ const ok = await setLocale(this.$i18n, langCode);
+ if (!ok) {
+ await ensureLocaleMessages(this.$i18n, langCode);
+ }
} catch {
// Locale pack may be unavailable in tests or offline shells.
}

diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index 43743646..bdfbb486 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -2700,11 +2700,11 @@ export default {
async onLanguageChange(langCode) {
const code = normalizeUiLocaleCode(langCode);
try {
+ await setLocale(this.$i18n, code);
await window.api.patch("/api/v1/config", {
language: code,
});
GlobalState.config.language = code;
- await setLocale(this.$i18n, code);
} catch (e) {
console.error("Failed to update language:", e);
}

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index f296d518..48f0774f 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -4232,13 +4232,13 @@ export default {
await this.onLanguageChange();
},
async onLanguageChange() {
+ await setLocale(this.$i18n, this.config.language);
await this.updateConfig(
{
language: this.config.language,
},
"language"
);
- await setLocale(this.$i18n, this.config.language);
},
async onAutoResendFailedMessagesWhenAnnounceReceivedChangeWrapper(value) {
this.config.auto_resend_failed_messages_when_announce_received = value;

diff --git a/meshchatx/src/frontend/components/settings/sections/LanguageSettingsSection.vue b/meshchatx/src/frontend/components/settings/sections/LanguageSettingsSection.vue
index 6b5360ec..fa80e736 100644
--- a/meshchatx/src/frontend/components/settings/sections/LanguageSettingsSection.vue
+++ b/meshchatx/src/frontend/components/settings/sections/LanguageSettingsSection.vue
@@ -11,16 +11,34 @@
</header>
<div class="settings-section__body space-y-3">
<select :value="language" class="input-field" @change="onSelect">
- <option value="en">English</option>
- <option value="de">Deutsch</option>
- <option value="ru">Русский</option>
- <option value="it">Italiano</option>
+ <option v-for="lang in languages" :key="lang.code" :value="lang.code">
+ {{ lang.name }}
+ </option>
</select>
</div>
</section>
</template>
<script>
+import { listLocaleCodes } from "../../../js/localeLoader.js";
+
+const LANGUAGE_NAMES = {
+ de: "Deutsch",
+ en: "English",
+ es: "Español",
+ fi: "Suomi",
+ fr: "Français",
+ it: "Italiano",
+ nl: "Nederlands",
+ ru: "Русский",
+ zh: "中文",
+};
+
+const discoveredLanguages = listLocaleCodes().map((code) => ({
+ code,
+ name: LANGUAGE_NAMES[code] || code,
+}));
+
export default {
name: "LanguageSettingsSection",
props: {
@@ -34,6 +52,11 @@ export default {
},
},
emits: ["change"],
+ computed: {
+ languages() {
+ return discoveredLanguages;
+ },
+ },
methods: {
onSelect(event) {
this.$emit("change", event.target.value);

diff --git a/meshchatx/src/frontend/js/localeLoader.js b/meshchatx/src/frontend/js/localeLoader.js
index 6945001b..c45f9182 100644
--- a/meshchatx/src/frontend/js/localeLoader.js
+++ b/meshchatx/src/frontend/js/localeLoader.js
@@ -2,11 +2,74 @@
const localeModules = import.meta.glob("../locales/*.json");
+/**
+ * Real vue-i18n Composer registered at app boot.
+ * Options API this.$i18n under legacy:false is a locale-only proxy without
+ * setLocaleMessage. Call sites pass that proxy, so loaders must fall back here.
+ * @type {import("vue-i18n").Composer | null}
+ */
+let registeredComposer = null;
+
+/**
+ * @param {unknown} obj
+ * @returns {boolean}
+ */
+function hasLocaleMessageApi(obj) {
+ return Boolean(obj && typeof obj.setLocaleMessage === "function");
+}
+
+/**
+ * @param {unknown} composer
+ * @returns {string[]}
+ */
+function listAvailableLocales(composer) {
+ const raw = composer?.availableLocales;
+ if (Array.isArray(raw)) {
+ return raw;
+ }
+ if (raw && typeof raw === "object" && Array.isArray(raw.value)) {
+ return raw.value;
+ }
+ return [];
+}
+
+/**
+ * @param {import("vue-i18n").I18n | import("vue-i18n").Composer | null | undefined} i18nOrComposer
+ * @returns {import("vue-i18n").Composer | null}
+ */
function resolveComposer(i18nOrComposer) {
+ if (i18nOrComposer) {
+ if (hasLocaleMessageApi(i18nOrComposer.global)) {
+ return i18nOrComposer.global;
+ }
+ if (hasLocaleMessageApi(i18nOrComposer)) {
+ return i18nOrComposer;
+ }
+ }
+ if (hasLocaleMessageApi(registeredComposer)) {
+ return registeredComposer;
+ }
+ return null;
+}
+
+/**
+ * Register the app i18n instance so Options API this.$i18n proxies can load packs.
+ * @param {import("vue-i18n").I18n | import("vue-i18n").Composer | null | undefined} i18nOrComposer
+ */
+export function registerUiI18n(i18nOrComposer) {
if (!i18nOrComposer) {
- return null;
+ registeredComposer = null;
+ return;
+ }
+ if (hasLocaleMessageApi(i18nOrComposer.global)) {
+ registeredComposer = i18nOrComposer.global;
+ return;
+ }
+ if (hasLocaleMessageApi(i18nOrComposer)) {
+ registeredComposer = i18nOrComposer;
+ return;
}
- return i18nOrComposer.global || i18nOrComposer;
+ registeredComposer = null;
}
/**
@@ -83,7 +146,7 @@ export async function ensureLocaleMessages(i18nOrComposer, code) {
if (!composer) {
return false;
}
- if (composer.availableLocales?.includes(code)) {
+ if (listAvailableLocales(composer).includes(code)) {
return true;
}
if (typeof composer.setLocaleMessage !== "function") {

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index 64ed62fc..fc5b1cd0 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -5,6 +5,7 @@ import vClickOutside from "./libs/clickOutside.js";
import DOMPurify from "dompurify";
import "./style.css";
import { injectMeshchatThemeVariables, vuetifyThemesFromTokens } from "./theme/designTokens.js";
+import { registerUiI18n } from "./js/localeLoader.js";
injectMeshchatThemeVariables();
@@ -43,6 +44,7 @@ const i18n = createI18n({
en: enMessages,
},
});
+registerUiI18n(i18n);
// init vuetify
import { createVuetify } from "vuetify";

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md b/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md
index 4dc85043..4c6ec42d 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md
@@ -36,6 +36,16 @@ Access attempts are logged. Repeated failures can trigger lockout when auth is e
Reset a forgotten password with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, then set a new password in the UI.
+### Demo mode and ALTCHA
+
+`MESHCHAT_DEMO_MODE=1` (or `--demo`) enables a public showcase profile: privacy mode on, plugins off, no outbound announces, and a default-deny HTTP mutation policy with mesh send blocked. Status reports `demo_mode: true`.
+
+When `MESHCHAT_ALTCHA_ENABLED=1`, login and setup require a valid [ALTCHA](https://altcha.org/docs/v2/widget-v3/) proof-of-work payload (widget v3, server challenges use `PBKDF2/SHA-256` by default). Set `MESHCHAT_ALTCHA_HMAC_KEY` to a long random secret on the server. Optional `MESHCHAT_ALTCHA_COST` tunes PoW difficulty. The widget loads from the bundled `altcha` npm package and fetches challenges from `/api/v1/auth/altcha/challenge`.
+
+`MESHCHAT_AUTH_PAGE_HINT` sets optional plain text on the login page (independent of demo mode). Demo Docker compose defaults to username and password hints for the showcase account.
+
+`MESHCHAT_AUTH_BYPASS=1` skips session auth for local testing only. Do not use it on internet-facing deployments.
+
## Transport security
- HTTPS and WSS are on by default.
@@ -59,7 +69,7 @@ Privacy mode does not disable Reticulum mesh traffic. It limits clearnet fetches
On Linux, MeshChatX can enable two complementary in-process sandboxes when supported:
-- **Landlock** restricts filesystem paths the backend may use
+- **Landlock** restricts filesystem paths the backend may use. User-local pipx tools (for example Argos Translate under `~/.local`) need explicit read and sometimes write roots. See **Linux sandboxing** in Platform guides.
- **Seccomp-BPF** installs a syscall denylist (via libseccomp) that blocks kernel-admin and related calls a mesh client does not need
Both auto-enable when available and fall back to a no-op when the platform, kernel, or libraries cannot support them. Override with:
@@ -77,11 +87,14 @@ Use **Blocked** for specific destination hashes. Combine with sieve filters, mes
## Data backup
-Database backups land in `database-backups/`. Export snapshots from **About** or the API. Electron crash recovery can offer restore when integrity checks fail.
+Database backups land in `database-backups/`. Before a schema upgrade, MeshChatX writes a `backup-pre-migrate-v*-to-v*.zip` in that folder unless `MESHCHAT_SKIP_PRE_MIGRATE_BACKUP=1`. After a successful migration it runs `PRAGMA quick_check` and keeps the five newest pre-migrate zips (override with `MESHCHAT_PRE_MIGRATE_BACKUP_KEEP`, `0` disables pruning). If the stored schema version is newer than this build supports, startup refuses to migrate. Only one process should use a given identity storage directory at a time (storage lock). Roll back by restoring a backup zip and running an older MeshChatX build. Export snapshots from **About** or the API. Electron crash recovery can offer restore when integrity checks fail.
-CLI restore example:
+CLI examples:
```bash
+meshchatx --list-backups
+meshchatx --export-backup /path/to/export.zip
+meshchatx --export-backup backup-20260101-120000.zip /path/to/copy.zip
meshchatx --restore-db /path/to/backup.zip
```

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
index db87df23..aa55278d 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
@@ -41,7 +41,13 @@ Manual run with a named volume for persistence:
```bash
docker run -d --name reticulum-meshchatx \
--restart unless-stopped \
+ --init \
+ --user 1000:1000 \
--security-opt no-new-privileges:true \
+ --cap-drop ALL \
+ --read-only \
+ --tmpfs /tmp:noexec,nosuid,size=256m \
+ --tmpfs /home/meshchat:nosuid,size=64m \
--cpus=2.0 \
--memory=1g \
--memory-reservation=256m \
@@ -55,6 +61,19 @@ Default Compose maps `127.0.0.1:8000` on the host to port `8000` in the containe
To bind a host directory instead, mount it at `/config`. The container runs as UID 1000. The host directory must be writable by that user.
+Run only **one** MeshChatX instance per `/config` volume. Startup takes an exclusive storage lock so schema migration and runtime do not overlap. For Docker or Coolify, use a single replica on that volume and replace containers in a rolling stop-then-start order instead of two replicas sharing one config path.
+
+### Public demo instance (Coolify)
+
+For a read-only mesh showcase on [Coolify](https://coolify.io/docs/knowledge-base/docker/compose), deploy [`docker-compose.demo.yml`](../../docker-compose.demo.yml). For a normal (non-demo) Coolify deployment, use [`docker-compose.coolify.yml`](../../docker-compose.coolify.yml).
+
+- `MESHCHAT_DEMO_MODE=1` blocks outbound mesh actions and almost all API mutations.
+- `MESHCHAT_AUTH=1` with default showcase password `demo` (`MESHCHAT_DEMO_AUTH_PASSWORD`).
+- Optional `MESHCHAT_AUTH_PAGE_HINT` shows custom text on the login page (for example `Username: demo` and `Password: demo`). Demo compose sets a default hint.
+- `MESHCHAT_ALTCHA_ENABLED=1` and a strong `MESHCHAT_ALTCHA_HMAC_KEY` (required in demo compose via `:?`). The UI uses ALTCHA widget v3 with `PBKDF2/SHA-256` challenges from `/api/v1/auth/altcha/challenge`.
+- Assign a domain with container port **8000**, for example `https://meshchatx.example.com:8000`.
+- Do not set `MESHCHAT_AUTH_BYPASS=1` on a public host.
+
## Python wheel
1. Download `reticulum_meshchatx-*-py3-none-any.whl` from [releases](https://github.com/Quad4-Software/MeshChatX/releases).

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/linux-sandbox.md b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/linux-sandbox.md
index 294c1ec3..d36dbdcb 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/linux-sandbox.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/linux-sandbox.md
@@ -11,6 +11,8 @@ MeshChatX also applies optional **in-process** Linux sandboxes when available:
Those layers fall back cleanly when unsupported. Firejail and Bubblewrap remain useful as an outer wrapper.
+**Landlock and user-local tools:** When Landlock is active, MeshChatX whitelists common pipx paths (`~/.local/bin`, `~/.local/share/pipx`) and Argos Translate data under `~/.local/share/argos-translate` so local translation and similar CLIs keep working. Tools installed elsewhere (for example only under `~/.nvm`) or symlink shims that point outside those trees may still fail with permission errors. Disable Landlock temporarily with `MESHCHAT_LANDLOCK=0` while debugging PATH-only failures.
+
**Containers:** If you already run MeshChatX with Docker or Podman, that is a different isolation model, this document is aimed at **host-installed** `meshchatx` (or `meshchat`).
## Prerequisites

diff --git a/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json b/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
index fa384b50..ef9a049a 100644
--- a/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
+++ b/meshchatx/src/frontend/public/vendor/visualiser-wasm/integrity.json
@@ -1,6 +1,6 @@
{
"version": "1.2.0",
- "wasm": "sha384-6bz7NFPqx6GPHO+/fb7VihhQ9OCCl5yucvUB+Z1weaBTIB/Le2ekjfJklRKbi8TD",
+ "wasm": "sha384-K3R09OX1Y9aDbbJb7mYPJuNJGkpIGLIoM25OVaKA7YveH8EGfncHgvkBzRzPVtre",
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
"wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
}

diff --git a/tests/frontend/LanguageSelector.test.js b/tests/frontend/LanguageSelector.test.js
index a67182c3..9c5fb046 100644
--- a/tests/frontend/LanguageSelector.test.js
+++ b/tests/frontend/LanguageSelector.test.js
@@ -1,5 +1,6 @@
-import { mount } from "@vue/test-utils";
+import { mount, flushPromises } from "@vue/test-utils";
import { describe, it, expect, vi } from "vitest";
+import { nextTick } from "vue";
import LanguageSelector from "@/components/LanguageSelector.vue";
describe("LanguageSelector.vue", () => {
@@ -68,6 +69,8 @@ describe("LanguageSelector.vue", () => {
const deButton = wrapper.findAll(".fixed button")[1];
await deButton.trigger("click");
+ await flushPromises();
+ await nextTick();
expect(wrapper.emitted("language-change")).toBeTruthy();
expect(wrapper.emitted("language-change")[0]).toEqual(["de"]);
@@ -80,6 +83,8 @@ describe("LanguageSelector.vue", () => {
const enButton = wrapper.findAll(".fixed button")[0];
await enButton.trigger("click");
+ await flushPromises();
+ await nextTick();
expect(wrapper.emitted("language-change")).toBeFalsy();
expect(wrapper.find(".fixed").exists()).toBe(false);

diff --git a/tests/frontend/LanguageSwitcherOptionsApi.regression.test.js b/tests/frontend/LanguageSwitcherOptionsApi.regression.test.js
new file mode 100644
index 00000000..1058557d
--- /dev/null
+++ b/tests/frontend/LanguageSwitcherOptionsApi.regression.test.js
@@ -0,0 +1,153 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * PROVED: setLocale(this.$i18n) under vue-i18n legacy:false loads packs and switches UI.
+ * Bug class: incomplete-fix (Options API $i18n proxy lacks setLocaleMessage).
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { createI18n } from "vue-i18n";
+import { nextTick } from "vue";
+import {
+ listLocaleCodes,
+ normalizeUiLocaleCode,
+ registerUiI18n,
+ setLocale,
+} from "../../meshchatx/src/frontend/js/localeLoader.js";
+import LanguageSelector from "../../meshchatx/src/frontend/components/LanguageSelector.vue";
+import en from "../../meshchatx/src/frontend/locales/en.json";
+import de from "../../meshchatx/src/frontend/locales/de.json";
+
+function createAppI18n() {
+ return createI18n({
+ legacy: false,
+ locale: "en",
+ fallbackLocale: "en",
+ messages: { en },
+ });
+}
+
+describe("language switcher Options API $i18n regression", () => {
+ beforeEach(() => {
+ registerUiI18n(null);
+ document.documentElement.lang = "en";
+ });
+
+ afterEach(() => {
+ registerUiI18n(null);
+ });
+
+ it("exposes a locale-only proxy on this.$i18n under legacy:false", () => {
+ const i18n = createAppI18n();
+ const wrapper = mount({ template: "<div />" }, { global: { plugins: [i18n] } });
+ expect(typeof wrapper.vm.$i18n.setLocaleMessage).toBe("undefined");
+ expect(typeof wrapper.vm.$i18n.locale).toBe("string");
+ });
+
+ it("fails closed without registerUiI18n when only the Options API proxy is passed", async () => {
+ const i18n = createAppI18n();
+ const wrapper = mount({ template: "<div />" }, { global: { plugins: [i18n] } });
+ expect(await setLocale(wrapper.vm.$i18n, "de")).toBe(false);
+ expect(i18n.global.locale.value).toBe("en");
+ });
+
+ it("setLocale(this.$i18n) switches after registerUiI18n", async () => {
+ const i18n = createAppI18n();
+ registerUiI18n(i18n);
+ const Comp = {
+ template: `<span id="label">{{ $t("app.language") }}</span>`,
+ methods: {
+ async switchTo(code) {
+ return setLocale(this.$i18n, code);
+ },
+ },
+ };
+ const wrapper = mount(Comp, { global: { plugins: [i18n] } });
+ const before = wrapper.find("#label").text();
+ expect(await wrapper.vm.switchTo("de")).toBe(true);
+ await nextTick();
+ expect(i18n.global.locale.value).toBe("de");
+ expect(wrapper.vm.$i18n.locale).toBe("de");
+ expect(document.documentElement.lang).toBe("de");
+ expect(wrapper.find("#label").text()).toBe(de.app.language);
+ expect(wrapper.find("#label").text()).not.toBe(before);
+ });
+
+ it("LanguageSelector selectLanguage applies locale via Options API proxy", async () => {
+ const i18n = createAppI18n();
+ registerUiI18n(i18n);
+ const Parent = {
+ components: { LanguageSelector },
+ template: `
+ <div>
+ <span id="msg">{{ $t("app.language") }}</span>
+ <LanguageSelector ref="lang" @language-change="onLanguageChange" />
+ </div>
+ `,
+ data() {
+ return { lastEmitted: null };
+ },
+ methods: {
+ async onLanguageChange(langCode) {
+ this.lastEmitted = normalizeUiLocaleCode(langCode);
+ // Parent still persists config. Locale already applied by LanguageSelector.
+ },
+ },
+ };
+ const wrapper = mount(Parent, {
+ global: {
+ plugins: [i18n],
+ stubs: { MaterialDesignIcon: true, Teleport: true },
+ },
+ });
+ const selector = wrapper.findComponent({ name: "LanguageSelector" });
+ expect(selector.exists()).toBe(true);
+ await selector.vm.selectLanguage("de");
+ await flushPromises();
+ await nextTick();
+ expect(i18n.global.locale.value).toBe("de");
+ expect(wrapper.vm.lastEmitted).toBe("de");
+ expect(wrapper.find("#msg").text()).toBe(de.app.language);
+ });
+
+ it("App-style handler recovers when only the Options API proxy is available", async () => {
+ const i18n = createAppI18n();
+ registerUiI18n(i18n);
+ const Comp = {
+ template: `<span id="msg">{{ $t("app.language") }}</span>`,
+ methods: {
+ async onLanguageChange(langCode) {
+ const code = normalizeUiLocaleCode(langCode);
+ await setLocale(this.$i18n, code);
+ },
+ },
+ };
+ const wrapper = mount(Comp, { global: { plugins: [i18n] } });
+ await wrapper.vm.onLanguageChange("fr");
+ await nextTick();
+ expect(i18n.global.locale.value).toBe("fr");
+ expect(wrapper.find("#msg").text()).not.toBe(en.app.language);
+ });
+
+ it("oracle: every bundled pack is reachable via Options API setLocale", async () => {
+ const i18n = createAppI18n();
+ registerUiI18n(i18n);
+ const wrapper = mount({ template: "<div />" }, { global: { plugins: [i18n] } });
+ const packs = listLocaleCodes();
+ expect(packs.length).toBeGreaterThanOrEqual(8);
+ for (const code of packs) {
+ expect(await setLocale(wrapper.vm.$i18n, code)).toBe(true);
+ expect(i18n.global.locale.value).toBe(code);
+ expect(listAvailableLocalesOrThrow(i18n).includes(code)).toBe(true);
+ }
+ });
+});
+
+function listAvailableLocalesOrThrow(i18n) {
+ const locales = i18n.global.availableLocales;
+ if (!Array.isArray(locales)) {
+ throw new Error("expected availableLocales array");
+ }
+ return locales;
+}

diff --git a/tests/frontend/LocaleThemeRegressions.test.js b/tests/frontend/LocaleThemeRegressions.test.js
index 1df8ba8e..b8c79064 100644
--- a/tests/frontend/LocaleThemeRegressions.test.js
+++ b/tests/frontend/LocaleThemeRegressions.test.js
@@ -145,9 +145,14 @@ describe("locale and theme regressions", () => {
expect(ctx.config.language).toBe("ru");
});
- it("onLanguageChange normalizes zh-cn before PATCH and applyLocale", async () => {
- const updateConfig = vi.fn().mockResolvedValue(undefined);
- const applyLocale = vi.fn().mockResolvedValue(undefined);
+ it("onLanguageChange applies locale before PATCH so UI is not stuck on English", async () => {
+ const order = [];
+ const updateConfig = vi.fn().mockImplementation(async () => {
+ order.push("updateConfig");
+ });
+ const applyLocale = vi.fn().mockImplementation(async () => {
+ order.push("applyLocale");
+ });
const ctx = {
updateConfig,
applyLocale,
@@ -155,8 +160,9 @@ describe("locale and theme regressions", () => {
await App.methods.onLanguageChange.call(ctx, "zh-cn");
- expect(updateConfig).toHaveBeenCalledWith({ language: "zh" }, "language");
expect(applyLocale).toHaveBeenCalledWith("zh");
+ expect(updateConfig).toHaveBeenCalledWith({ language: "zh" }, "language");
+ expect(order).toEqual(["applyLocale", "updateConfig"]);
});
});

diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index f479b9a0..84802a64 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -112,13 +112,14 @@ describe("SettingsPage: config persistence (PATCH and related)", () => {
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { language: "de" });
});
- it("onLanguageChange applies setLocale after PATCH", async () => {
+ it("onLanguageChange applies setLocale before PATCH", async () => {
const setLocaleSpy = vi.spyOn(localeLoader, "setLocale").mockResolvedValue(true);
const w = await mountSettingsPage(api);
w.vm.$i18n = { global: { locale: { value: "en" } } };
w.vm.config.language = "ru";
await w.vm.onLanguageChange();
expect(setLocaleSpy).toHaveBeenCalledWith(w.vm.$i18n, "ru");
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { language: "ru" });
setLocaleSpy.mockRestore();
});

diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index 1992c129..64d087e1 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -414,6 +414,15 @@ describe("behavior contracts: locale, theme, and call audio", () => {
expect(app).toContain("setLocale(this.$i18n");
});
+ it("main.js registers the real i18n composer for Options API locale switches", () => {
+ const main = readSource("meshchatx/src/frontend/main.js");
+ expect(main).toContain("registerUiI18n");
+ expect(main).toContain("registerUiI18n(i18n)");
+ const loader = readSource("meshchatx/src/frontend/js/localeLoader.js");
+ expect(loader).toContain("export function registerUiI18n");
+ expect(loader).toContain("hasLocaleMessageApi");
+ });
+
it("Settings language change applies vue-i18n locale after PATCH", () => {
const settings = readSource("meshchatx/src/frontend/components/settings/SettingsPage.vue");
expect(settings).toContain("async onLanguageChange()");

diff --git a/tests/frontend/localeLoader.test.js b/tests/frontend/localeLoader.test.js
index 690c1f6a..96e6c103 100644
--- a/tests/frontend/localeLoader.test.js
+++ b/tests/frontend/localeLoader.test.js
@@ -1,15 +1,20 @@
// SPDX-License-Identifier: 0BSD
-import { describe, expect, it, vi } from "vitest";
+import { describe, expect, it, afterEach } from "vitest";
import { createI18n } from "vue-i18n";
import {
ensureLocaleMessages,
listLocaleCodes,
normalizeUiLocaleCode,
+ registerUiI18n,
setLocale,
} from "../../meshchatx/src/frontend/js/localeLoader.js";
describe("localeLoader", () => {
+ afterEach(() => {
+ registerUiI18n(null);
+ });
+
it("lists locale codes with english first", () => {
const codes = listLocaleCodes();
expect(codes[0]).toBe("en");
@@ -74,4 +79,17 @@ describe("localeLoader", () => {
await expect(ensureLocaleMessages(i18n, code)).resolves.toBeTypeOf("boolean");
}
});
+
+ it("registerUiI18n lets setLocale recover from a locale-only proxy", async () => {
+ const i18n = createI18n({ legacy: false, locale: "en", messages: { en: { hi: "hi" } } });
+ const proxy = {
+ locale: "en",
+ availableLocales: ["en"],
+ fallbackLocale: "en",
+ };
+ expect(await setLocale(proxy, "de")).toBe(false);
+ registerUiI18n(i18n);
+ expect(await setLocale(proxy, "de")).toBe(true);
+ expect(i18n.global.locale.value).toBe("de");
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────